动态下发 so 库在 Android APK 安装包瘦身方面的应用
| 导语 众所周知 Android 加载 so 文件本身就是一种运行时动态加载可执行代码的行为,所以把 so 做成动态下发的没有什么技术风险,不过要把这项技术稳定落地到实际生产项目中还是有不少麻烦的问题。本文根据实际项目经验,分享一些 so 动态化关键技术点和需要避免的坑。
需求价值
一般来说,越是成熟的 Android 项目,Native 代码的贡献量就越多,以往 APK 体积的主要占比大都是资源文件,不过现在 Native 代码带来的 so 体积占比也很可观了,所以 so 动态化的价值越来越凸显。另一方面,现在支持 arm64 的 Android 项目也越来越多,Google Play 更是强制要求支持 arm64,所以有的 Android 项目需要内置两种甚至以上 abi 支持(比如 B 站客户端项目就同时支持 arm32/arm64/x86 三种,以往还支持 arm5),结果就是 so 体积成倍地上涨。因此,能不能将非主要的 abi 相关的 so 文件动态化,也成为了国内 Android 项目瘦身优化不得不优先考虑的问题。
此外,一些第三方 SDK 库也自带了不少 so 库(比如腾讯视频 SDK,以前我在接入这个 SDK 的时候,项目本身才 15 MB 体积,而 SDK 自身 so 已经占了 17 MB),或许是为了精简第三方 SDK 带来的体积,或许是为了隔离第三方 SDK 的 API(项目只自身依赖自己定义的业务相关性 API,通过依赖注入的方式访问第三方 SDK 的实现,这样以后更换 SDK 的时候只需要切换依赖注入的形式即可),都需要具体的 so 动态化方案提供技术支撑。
动态化需要解决的问题
动态下发 so 库,看上只是把原本就算运行时动态加载的 so 文件,从 APK 安装包里面抽离出来,工作流程上变化不大,但实际上这也是一种完备的插件化技术,也就是说所有插件需要面临问题的问题我们统统需要考虑。我在以往的文章 使用 SO 库时要注意的一些问题 [1] 中简单谈过 so 动态化的一些问题,不过那些问题也仅仅是在 DEMO 项目里进行挖掘,当具体投入到生产项目中时,面临的挑战要严峻许多。以下我针对实际投产时遇到的问题进行一一分析讲解。
1. 安全性问题
动态化本质上就是运行时加载可执行代码,而所有可执行代码在拷贝安装到安全路径(比如 Android 的 data/data 内部路径)之前,都有被劫持或者破坏的风险。so 动态化也不得不考虑这个安全性问题,最好的做法是每次加载 so 库之前都对其做一次安全性校验。考虑到检查带来的时间成本,可以假设内部路径是无条件可信的(对 Android 来说, data/data 路径在设备 root 情况下是不安全的;而且除了劫持风险外,内部路径文件有可能被应用自身一些不当文件操作给破坏导致插件不完整,因此如果要考虑绝对安全,内部路径插件被加载也必须做安全检查),在 so 文件拷贝到内部路径后单独做一次检查,检查失败就丢弃文件走 fail 逻辑,检查通过就生成一个 flag 文件作为标志,以后通过判断 flag 标志是否存在来决定是否需要执行安全检查。
2. 版本控制问题
和一般的插件化方案一样,so 动态化也必须处理好版本控制问题:从 APK 里把 so 剥离出来后,我们除了要保证 so 文件的安全性,还要保证 so 文件和依赖它的宿主代码是 API 兼容的(严格上必须要求版本一致,至少做到向前兼容)。如果不需要一般插件那样考虑升降级问题,那也必须做到 so 文件和 APK 包版本是一致的:宿主下载相应版本的 so 文件后,安装到指定的版本路径;宿主版本升级后必须再次下载新版本的 so 文件而不能受到存量旧版本 so 文件的干扰(如果需要做到动态升降级,还需要保留最近一两个版本的存量 so 文件,用于 fallback 逻辑需要)。
版本控制除了解决插件的 API 兼容问题,还可以实现“即时吊销”策略。设想我们发布了某一个版本宿主 APK 和与之对应的 so 插件包,而这个版本的 so 是有 Bug 的可能导致 APP 崩溃。通过版本控制流程,我们可以在服务端禁用这个版本的 so 插件,从而使客户端进入“so 插件不可用”的逻辑,而不至于执行有问题的代码。(如果 so 插件支持动态升降级,还可以配置让客户端强制更新到 fix 插件版本,或者 fallback 回没有问题的存量旧版。)
从框架设计上,版本控制涉及动态化的 Update 和 Install 两个环节,具体实现代码可以参考 Updater#doUpdatePolicy() [3]和 Installer#isInstalled() [4]。
3. abi 兼容性判断
这个问题也可是说是版本控制问题上面的一个分支问题。考虑到框架的完备性,框架自身应该能自动设别和处理好 abi 兼容问题,而不是通过 so 插件的打包流程来规避这个问题(容错)。
4. System#load 加载代码侵入问题
侵入性问题也是 so 插件特有的问题,这个问题跟 Android Framework 加载 so 库的具体方式有关。Framework 一般不让用户直接通过 dlopen 函数加载动态链接库,而是封装了以下两种加载 so 库的方式(实际上第二种最终也是需要通过 libName 找到具体的 so 文件路径,再通过文件路径加载 so 库,与第一种方式殊途同归):
// 方式一:通过 so 文件路径加载
public static void load(String filename) {
}
// 方式二:通过 so 库名加载
public static void loadLibrary(String libname) {
}
}
方式二
以 System.loadLibrary("xxx")
的方式来加载 so 文件 libxxx.so
,而将 so 文件动态化之后,我们需要将 so 文件安装到内部安全路径,在通过 方式一
以 System.load("{安全路径}/libxxx.so")
的方式来加载。这种方案是大部分 so 动态化项目采用的方案,一直以来也都能稳定工作,不过我们也在这个方案里发现了不少麻烦。采用 方式一
作为 so 动态化的方案,意味着代码里要写死 System.load("{安全路径}/libxxx.so")
。这样一来,首先我们在代码调节阶段就蛋疼了,Native 代码在开发阶段完全可以用传统的内置方案进行调试,在集成阶段再按动态化的方案打包,这也就意味着我们必须频繁地在 方式一
和 方式二
直接来回修改,代码侵入性问题非常严重。然而这还不是最麻烦的问题,对于第三方的 SDK 项目的动态化问题,如果 SDK 项目本身的 so 库是以 方式二
的方式加载(正常的开发方式,对于一些自身就带有 so 文件下载逻辑的 SDK 项目,则很可能是以 方式一
加载的,这种情况下反而问题不大),则可能需要借助 ASM 这种“曲线救国”的方式来把 SDK 项目里 so 加载的相关代码修改成 方式一
;或者选择在准备好 so 插件之后立即以 方式一
把插件里的所有 so 文件加载进宿主,这样可以兜住插件里 方式二
的加载代码(如果目标 so 库已经加载过一次,则 方式二
加载代码变成一个空实现)。
解决 so 动态化的 System#load 代码侵入问题,要借鉴 Android 热修复技术方案的思路:按 方式二
,即通过 System#loadLibrary("xxx" )
加载 so 库, Android Framework 会遍历当前上下文的 ClassLoader 实例里的 nativeLibraryDirectories 数组,在数组里所有的文件路径下查找文件名为 libxxx.so
的文件,所以我们的解决思路就是在安装好 so 插件之后,将其所在的内部安全路径注入到这个 nativeLibraryDirectories 数组里,即可实现通过 方式二
加载。(思路虽然简单清晰,不过 在实际应用中还是有不少问题,以后在具体的解决方案中进行详细说明。)
具体方案
1. 系统加载 so 库的工作流程
当我们调用 System#loadLibrary("xxx" )
后,Android Framework 都干了些了啥?
PMS install:安装 APK 包的时候,PackageManagerService 根据当前设备的 abi 信息,从 APK 包里拷贝相应的 so 文件。
Native classpath:启动 APP 的时候, Android Framework 创建应用的 ClassLoader 实例,并将当前应用相关的所有 so 文件所在目录注入到当前 ClassLoader 相关字段。
so loading:调用
System.loadLibrary("xxx")
, Android Framework 从当前上下文 ClassLoader 实例(或者用户指定)的目录数组里查找并加载名为libxxx.so
的文件。jni calling:调用 so 相关 JNI 方法。
具体流程以及方法调用链这里不做深入讨论,有兴趣这里推荐老罗的 Dalvik虚拟机JNI方法的注册过程分析 [6] 一文。根据这个流程以及上面提到的“加载代码侵入问题”,按照 System.loadLibrary("xxx")
加载代码和 JNI 方法相关类(以下统称 JNI 代码)所在的 ClassLoader 实例不同,so 动态化技术可以分为“JNI 代码隔离”和“JNI 代码内置”两种解决方案。
2. JNI 代码隔离方案
顾名思义,就是将涉及到的 JNI 代码拆解到一个独立的模块,一同打包进 so 插件包里。运行时动态加载 so 库的时候,先给 so 插件创建一个插件 ClassLoader,在插件 ClassLoader 内部执行“so loading”和“jni calling”。代码隔离方案的优点是是能够做到插件模块编译隔离,其他模块的代码无法 Reference 插件里面的相关 JNI 方法,不容易干扰 JNI 调用的生命周期,后续维护成本低(这也是一般的插件化方案需要做到的目标)。同时缺点也是非常明显的:根据项目历史包袱的具体情况,模块拆解成本可能比动态化改造的收益还大。因此,代码隔离方案比较适合新增的 Native 模块,一开始就奔着动态化、延迟加载的方向去。
3. JNI 代码内置方案
nativeLibraryDirectories
注入 so 插件路径带来的 集合并发修改 问题。由于 nativeLibraryDirectories 的具体实现是一个 ArrayList 实例,其元素读写操作自身是不保证线程安全的,而我们在 Worker 线程加载 so 插件的环节最后需要将新的 so 文件路径注入到 ArrayList 集合里,如果这时候刚好有另一个线程因为执行“so loading”操作而正在遍历集合元素,则会抛出 ConcurrentModificationException(ArrayList 内部实现)。解决并发修改问题的思路有两种:
给“so loading”和“ so 文件路径注入”这两种操作同时上锁,锁的实例是 so 相关的 ClassLoader 实例。
在所有“so loading”操作之前(比如冷启动初始化环节)就预先注入预留好的 so 文件路径。
System.loadLibrary("xxx")
调用,同样容易造成代码污染问题;而思路 2 总感觉有点违反程序设计的一般原则(有些 so 插件可能基本用不上,犯不着在一开始就把其路径注入进来),具体取舍要看项目实际情况。作为补充,思路 1 可以再优化一下:为了避免加锁操作带来的代码污染,可以绕个弯子在编译阶段通过 ASM 手段给自动给所有“so loading”上锁;或者在往 ClassLoader 注入路径的时候,不要在原有的 nativeLibraryDirectories 集合上做修改,而是重新 new 一个 List 实例把所有的路径都拷贝到新集合上,最后再整体塞回去 ClassLoader,避免并发修改异常,代价是允许出现并发读脏数据问题(不至于崩溃)。4. 处理 dlopen 问题
dlopen
是 Native 开发比较熟悉的一个函数,其功能是以指定模式加载指定的动态链接库(使用 dlclose 来卸载打开的库)。实际上,Android Framework 加载 so 库的 System.loadLibrary("xxx")
调用,最后也是通过 dlopen
来实现 ,大致的调用路径如下:Sysytem#loadLibrary --> Sysytem#load --> Runtime#nativeLoad
Java +
| Native
dvmLoadNativeCode --> dlopen
在 NDK 开发中,如果我们有两个 so 文件:libxxx.so 和 liblog.so(后者是基础库,前者需要依赖后者的 API),xxx 需要动态链接 log,具体体现在 CMake 配置如下:
...
TARGET_LINK_LIBRARIES(xxx liblog.so)
...
System.loadLibrary("xxx")
的时候,Android Framework 会通过上面提到的调用链最终通过 dlopen 加载 libxxx.so 文件,并接着通过其依赖信息,自动使用 dlopen 加载 liblog.so(第二步没有返回 System#load,而是直接在 Native 层面执行)。对于熟悉 Native 开发的同学来说可能司空见惯,但对于只在第三方 SDK 里接触过 so 文件的同学来说,应该不太知道这一点。然而恰恰正是这一点,给 so 动态化添加了非常大的困难,也让我们在具体的实践项目中吃了很大的亏。根据项目经验,现在无论是插件化技术,或者是热修复技术,里面关于动态加载 so 文件的技术方案应该相当成熟,所有的坑都踩得七七八八,就算有没有解决的坑,那应该也不不会严重到影响项目方案可行性的地步。所以一开始,我们把动态化方案主要的风险评估放在模块代码拆解方面,而完全没有担心技术风险。实际上,在 Android N 以前,只要你将 libxxx.so 和 liblog.so 所在的文件目录路径都注入到当前 ClassLoader 的 nativeLibraryDirectories 里,则在加载 so 插件的时候,这两个文件都能正常被找到。而从 N 开始情况就不一样了:libxxx.so 能正常加载,而 liblog.so 会出现加载失败错误。具体异常如下:
E/ExceptionHandler: Uncaught Exception java.lang.UnsatisfiedLinkError: dlopen failed: library "liblog.so" not found
at java.lang.Runtime.loadLibrary0(Runtime.java:xxx)
at java.lang.System.loadLibrary(System.java:xxx)
...
至于 Namespace 机制的工作原理了,可以简单认为是一个以 ClassLoader 实例 HashCode 为 Key 的 Map,Native 层通过 ClassLoader 实例获取 Map 里存放的 Value(也就是 so 文件路径集合)。
我之前琢磨着,Tinker 之所以一直没有把 dlopen 问题暴露出来,主要是因为 Tinker 是热修复框架,补丁插件里需要的 liblog.so 文件,往往在宿主里本来就有内置一份,所以只会导致热修复部分失效,而不会出现 liblog.so 找不到问题。而实际上好巧不巧,Tinker 在解决 Android N 的混合编译带来的热修复失败问题时,在往 ClassLoader 注入插件 so 文件路径的时候,会创建一个新的 AndroidNClassLoader 实例用来替换 APP 自身的 ClassLoader,这个替换的操作刚好一并兜住了 dlopen 问题。至于其他插件化框架里为何没有提到这个问题,大概是因为一般适合动态化改造的插件都比较轻量,一般不会有 Native 代码(就算有也往往没有 so 依赖)。
解决 dlopen 问题主要有以下几个思路:
自定义 System#load,加载 libxxx.so 前,先解析 libxxx.so 的依赖信息,再递归加载其依赖的 so 文件(推荐参考开源方案 SoLoader[7] )。
自定义 Linker,完全自己控制 so 文件的检索逻辑(推荐参考开源方案 ReLinker [8] )。
类似 Tinker,在合适的时机替换 ClassLoader 实例(这是我们现在投产的方案)。
5. so 依赖分析工具
上面提到的都是 so 动态化方案中的具体技术难题,剩下的都是一些繁琐的项目问题了(技术债务),比如上面提到的 so 依赖分析。想要把 so 动态化技术应用到 APK 的瘦身项目中来,除了分析哪些 so 文件体积占比比较大之外,最好的做法是将其依赖的所有 so 文件一定挪到插件包里。怎么了解 APK 里所有 so 文件具体的依赖信息呢?根据 so 文件模型手撸代码解析依赖信息固然可行,不过那都是大神干的活,吾等平凡之辈还是选择站在巨人的肩膀上。
这里推荐一款 Google 开源的 APK 解析工具 android-classyshark [9],除了提供分析 APK dex/so 依赖信息之外,它还提供了 GUI 可视化界面,非常适合快速上手。
其他问题
相关 JNI 类污染问题
System#loadLibrary("xxx" )
之类的代码写在 JNI 类的静态代码块,以保证在访问 JNI 之前一定会先完成 so 库加载。不过这实际上非常不“Best Practice”:一方面,加载 so 原本就属于一种动态化技术,其自身就存在失败的可能性,而且 Native 开发在 Android 上一直存在诸多“疑难杂症”,最好的办法是考虑所有 so 加载和 JNI 方法调用失败的可能性;另一方面,加载 so 文件本身就有些许性能损耗,在静态代码块中加载会加剧性能问题。最麻烦的是,so 动态化改造之后,如果项目后续开发中有人不小心在 so 插件尚未安装完成之前引用了相关的 JNI 类(比如访问静态方法),哪怕没有发生实际的方法调用,也会导致 JNI 类提前被 ClassLoader 加载,进而提前触发 System#loadLibrary("xxx" )
逻辑,触发 Crash。对于项目已有的 JNI 代码,如果存在“静态代码块加载 so 问题”,则在改造成动态化的时候,最好将相关加载代码挪出静态代码块,并且增加 so 加载失败时候的 onFail 逻辑,确保所有 so 加载和 JNI 方法调用都不会出现崩溃问题。
代码后续维护成本
按照以往的动态化项目经验,“比较稳定,代码变化不大,模块边界比较内聚”的业务比较适合动态化改造,所以 so 动态化应该优先选择这种类型的模块,无论是改造成“JNI 代码隔离方案”,还是后续的维护成本,都相对要小许多。对于那些代码耦合比较严重,版本迭代非常活跃的业务模块,这是一个典型的“在高速行驶的火车上更换引擎”的问题:在动态化改造的同时,FT 代码还在并行迭代,势必会产生许多冲突;对于耦合比较严重的代码,考虑投入产出比的话一般都会选择“JNI 代码内置”方案,没有对 JNI 代码进行编译隔离,所以非常容易导致 Crash;改造完成后,后续 FT 代码变动频繁,后续代码维护压力大,而且可能是。
目前我觉得比较靠谱的处理方案是从项目管理流程上找突破点,主要方向最好还是让 FT 开发自己负责自己模块的动态化改造工作,降低维护成本(考虑到业务团队跟质量团队之间绩效目标的冲突,可能难以推动)。同时需要尽量根据项目的实际需要完善动态化框架以及相关配到的指导文档,降低 FT 的接入成本。作为辅助,还需要给容易产生代码冲突的地方加上相应的静态检查 Case,以便及时发现问题。
持续集成、部署问题(CI/CD)
踩了上面一系列的坑,眼看着动态化技术方案完善得七七八八了,实际上 我们才刚刚开始而已!
首先,怎么编译出 so 插件包也是个技术活,这一点要根据具体的项目情况选择合适的方案(我们选用的是 Gradle 插件在 PackageApplication 阶段抽取目标资源文件)。这个是一个 CI 问题,换句话说就是我们需要一个稳定灵活的流水线,用于稳定编译我们指定版本的 so 插件包,而不是每次都通过非常手工、笨拙的方式编包。其次,插件包编译之后,不应该通过手工的方式把文件上传到后端,在填写相关的版本、依赖等配置信息。这是一个 CD 问题(Continuous Deployment),我们应该采用自动化的手段(哪怕只是脚本),在集成阶段之后收集需要的配置信息,自动上传到一个内部环境的管理平台(平台上我们可以查看每个版本的数据),在 Test/Release 阶段根据需要将指定版本的配置信息“一键导入”到测试、预发布环境,每个环节上都要尽量避免人工操作。
因此,从工程管理的角度来看,一个完备的动态化方案,必须涵盖集成、部署、加载框架三个流程的内容,而前面的两点是大多数动态化项目或者技术文章没有提及到的,往往容易被忽视。
Play Store 动态代码禁用问题
尾巴
插件有风险,投资须谨慎!
References
[1] http://kaedea.com/2016/06/04/android-dynamical-loading-04-so-problems/
[2] https://github.com/kaedea/android-dynamical-loading/blob/develop/android-frontia/frontia/src/main/java/moe/studio/frontia/PluginInstallerImpl.java#L54
[3] https://github.com/kaedea/android-dynamical-loading/blob/develop/android-frontia/frontia/src/main/java/moe/studio/frontia/PluginUpdaterImpl.java#L270
[4] https://github.com/kaedea/android-dynamical-loading/blob/develop/android-frontia/frontia/src/main/java/moe/studio/frontia/PluginInstallerImpl.java#L201
[5] https://github.com/Tencent/tinker/blob/dev/tinker-android/tinker-android-lib/src/main/java/com/tencent/tinker/lib/library/TinkerLoadLibrary.java
[6] https://blog.csdn.net/luoshengyang/article/details/8923483
[7] https://github.com/facebook/SoLoader
[8] https://github.com/KeepSafe/ReLinker
[9] https://github.com/google/android-classyshark
设计一个框架化框架
http://kaedea.com/2016/07/10/android-dynamical-loading-08-satrt-frontia/
Android Frontia
https://github.com/kaedea/android-dynamical-loading/tree/develop/android-frontia
如果您觉得我们的内容还不错,就请转发到朋友圈,和小伙伴一起分享吧~